歡迎來到第十一天!昨天我們跨出了巨大的一步:成功將第一個知識點 (keyPoint) 轉化為向量,並存入了 Supabase 這個雲端知識庫,順便還嘴了一下 Google 的文件沒寫好。我們的 AI 終於有了一個可以長期儲存記憶的地方!
但只有儲存是不夠的,一樣回到圖書查詢的例子,這就像是我們建立了一座宏偉的圖書館塞滿了書,卻還沒有任何有效率的搜尋方式,如果管理員只會把整排書架的書都搬出來,讓讀者自己一本本翻,那這座圖書館的體驗肯定糟透了,圖書管理員大概率隔天就不幹了。我們需要教會他如何根據讀者的問題,快速、準確地找出相關的書籍。
在我們開始打造聰明的「圖書館管理員」之前,有兩件前置作業必須完成:
充實我們的圖書館:Day 10 的腳本一次只能上一本書,效率太低。我們需要一個「批量上傳」的腳本,將 questions.json 中所有的知識點一次性上架。
確保搜尋的精準度:我們的搜尋必須是精準的,當使用者在回答「JavaScript」的問題時,我們不希望撈到「React」的參考資料。
完成這兩項準備後,我們就能正式打造 RAG 流程中最核心的武器——Supabase 資料庫函式,一個能實現高效語意搜尋的超級引擎。
撰寫一個批量處理腳本,將所有 keyPoints 向量化並存入 Supabase。
學習如何使用 pgvector 的 <=> 運算子,達到我們之前手刻的餘弦相似函數更好的效果。
設計並建立一個精準的 SQL 資料庫函式,能根據特定問題 ID 進行向量搜尋。
撰寫測試腳本,驗證我們的搜尋函式能準確地從完整的知識庫中,檢索出最相關的資訊。
首先,讓我們來解決 Day 10 腳本只能處理單一資料的問題。一個完整的知識庫才能讓我們後續的搜尋測試更有意義。
請在你的 scripts/ 資料夾下,建立一個新檔案 seed-all-vectors.js。這個腳本將會讀取整個 questions.json,並將每一個 keyPoint 都變成向量存入資料庫。
import { createClient } from '@supabase/supabase-js';
import { GoogleGenAI } from '@google/genai';
import dotenv from 'dotenv';
import questions from '../data/questions.json' with { type: "json" };
dotenv.config({ path: './.env.local' });
// 輔助函數:將陣列分割成指定大小的區塊
function chunkArray(array, chunkSize) {
  const chunks = [];
  for (let i = 0; i < array.length; i += chunkSize) {
    chunks.push(array.slice(i, i + chunkSize));
  }
  return chunks;
}
async function seedAll() {
  // 初始化客戶端 (使用 Day 10 設定好的環境變數)
  const gemini = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
  const supabase = createClient(
    process.env.SUPABASE_URL,
    process.env.SUPABASE_SERVICE_KEY
  );
  console.log('正在清空舊的 documents 資料...');
  const { error: deleteError } = await supabase.from('documents').delete().neq('id', 0);
  if (deleteError) {
    console.error('清空資料失敗:', deleteError.message);
    return;
  }
  console.log('舊資料已清空。');
  console.log('開始處理所有 questions.json 中的 keyPoints...');
  // 1. 將所有 keyPoints 攤平成一個列表
  const allKeyPoints = questions.flatMap(q => {
    // 只處理有 keyPoints 且其為陣列的題目
    if (q.keyPoints && Array.isArray(q.keyPoints)) {
      return q.keyPoints.map(kp => ({
        questionId: q.id,
        content: kp,
      }));
    }
    // 如果沒有 keyPoints,就回傳空陣列,flatMap 會自動忽略它
    return [];
  });
  console.log(`總共找到 ${allKeyPoints.length} 個 keyPoints 待處理。`);
  // 2. 將資料分塊,避免一次送出太多請求
  const chunks = chunkArray(allKeyPoints, 5); // 一次處理 5 筆
  try {
    for (const [index, chunk] of chunks.entries()) {
      console.log(`- 正在處理第 ${index + 1} / ${chunks.length} 批資料...`);
      const contents = chunk.map(kp => kp.content);
      // 3. 一次性產生多個 Embedding
      const response = await gemini.models.embedContent({
        model: 'gemini-embedding-001',
        contents: contents,
        config: { outputDimensionality: 768 },
      });
      const embeddings = response.embeddings.map(e => e.values);
      // 4. 準備要插入的資料
      const dataToInsert = chunk.map((kp, i) => ({
        content: kp.content,
        embedding: embeddings[i],
        question_id: kp.questionId,
      }));
      // 5. 一次性插入多筆資料
      const { error } = await supabase.from('documents').insert(dataToInsert);
      if (error) {
        console.error(`  寫入此批資料失敗: ${error.message}`);
      } else {
        console.log(`  成功寫入 ${chunk.length} 筆資料。`);
      }
    }
    console.log('🎉 所有 KeyPoints 已成功寫入 Supabase!');
  } catch (error) {
    console.error('批量處理過程中發生嚴重錯誤:', error.message);
  }
}
seedAll();
keyPoints 分批 (chunk) 處理,並利用 embedContent API 可以一次處理多個字串的特性,減少了請求的次數免得我們不小心超過免費流量額度。keyPoints時先做了個確認,因為目前questions.json中其實只有概念題有keyPoints,程式實作題則是完全依賴testCases有沒有通過,但我當初規劃結構時並沒有想得這麼全面,之後整合時我們會再修改介面與未來資料庫的內容。現在,執行這個腳本:
node scripts/seed-all-vectors.js
順利的話你應該會在終端機看到以下的訊息印出:
正在清空舊的 documents 資料...
舊資料已清空。
開始處理所有 questions.json 中的 keyPoints...
總共找到 10 個 keyPoints 待處理。
- 正在處理第 1 / 2 批資料...
  成功寫入 5 筆資料。
- 正在處理第 2 / 2 批資料...
  成功寫入 5 筆資料。
🎉 所有 KeyPoints 已成功寫入 Supabase!
腳本順利的如我們預期的完成了!馬上切回到 Supabase 的 Table Editor,你會發現 documents 表已經被我們完整的知識庫填滿了!
![]()  | 
|---|
| 圖1 :批量資料新增成功畫面 | 
知識庫準備就緒,現在來打造我們的搜尋引擎。我們要建立一個 SQL 函式,它不僅要能搜尋向量,還要能根據我們指定的 question_id 來過濾,實現精準打擊。
不過,該做的說明還是要做,到底什麼是 資料庫函式, Supabase列表中還有個 Edge Functions 這又是什麼? 是一樣東西嗎? 問得好!作為一個前端仔你不知道也是情有可原的!馬上根據 Supabase 的文獻總結以下兩者的差異,請參考以下的表格快速了解兩者的定義吧!
| 特性 | Database Functions (資料庫函式) | Edge Functions (邊緣函式) | 
|---|---|---|
| 執行環境 | 在你的 PostgreSQL 資料庫內部執行,離資料最近 | 在全球分佈的 Deno 伺服器邊緣節點上執行,離使用者最近 | 
| 主要語言 | SQL, PL/pgSQL (或其他資料庫支援的程序化語言) | TypeScript / JavaScript (WASM 也支援) | 
| 核心用途 | 資料密集型操作:複雜的資料查詢、資料驗證、多表交易 (Transaction) | 低延遲的業務邏輯:需要快速回應的 API、處理 Webhooks、與第三方服務 (如 Stripe) 整合 | 
| 外部 API 呼叫 | 困難。原生不支援,需要額外擴充才能做到。 | 非常容易。這是其主要設計用途之一,可以輕鬆 fetch 任何外部服務 | 
| 觸發方式 | 可透過 Client SDK 的 rpc() 呼叫,或是由資料庫事件 (如 INSERT, UPDATE) 自動觸發 (Triggers) | 
透過標準的 HTTP 請求 (GET, POST, etc.) 觸發,就像一個標準的 API 端點 | 
| 前端類比 | 像是資料庫內建的、高效的「預存程序 (Stored Procedure)」 | 像是你熟悉的 Next.js API Route 或任何 Serverless Function | 
| 何時選用? | 當你需要確保資料一致性、執行複雜的 SQL 計算 (像我們的向量搜尋),或是在一次操作中修改多張表時。 | 當你需要與外部服務溝通、提供一個公開的 API 端點給前端或其他服務呼叫,或是希望全球使用者都有極低的延遲時。 | 
大致概念理解後就回到主題吧,我們要創建一個資料庫函數在我們的後端服務中呼叫,去決定我們想取得多相近的資料、又想取得幾筆的這些細節,如果全部撈出來再一筆筆的算餘弦距離那肯定會很沒效率,有好用的工具就不要客氣吧! 照 Supabase 官方文件的說法,建立資料庫函數有數種方式,我們這邊選最簡單的直接用 Sql語法建立。
現在,我們要執行一些 SQL 指令來賦予我們的資料庫搜尋能力。這會分成兩部分:首先,建立一個聰明的「搜尋函式」;接著,為這個函式建立「索引」,大幅提升查詢速度。
請回到 Supabase 的「SQL Editor」,點擊「New query」,貼上只有建立函式的程式碼:
-- 建立一個函式,用來搜尋特定問題下,語意最相關的知識點
create or replace function public.match_documents (
  query_embedding vector(768), -- 用來查詢的向量
  p_question_id text,          -- 【關鍵】要匹配的問題 ID
  match_threshold double precision, -- 相似度的門檻值
  match_count int              -- 最多回傳幾筆結果
)
returns table ( -- 定義回傳的格式
  id bigint,
  content text,
  similarity double precision
)
language sql
stable
as $$
  select
    d.id,
    d.content,
    (1 - (d.embedding <=> query_embedding)) as similarity
  from public.documents as d
  -- 【關鍵】過濾條件:確保只在當前問題的 keyPoints 中搜尋
  where d.question_id = p_question_id
    and (1 - (d.embedding <=> query_embedding)) > match_threshold
  -- 根據相似度由高到低排序
  order by similarity desc
  -- 限制回傳的筆數
  limit match_count;
$$;
keyPoints 範圍內進行。點擊「RUN」執行,應該會在下方的視窗看到 Success 的字眼!我們的 AI 大腦就學會了第一個技能:精準回憶!
切到 Database => Functions 的頁面,檢查一下是否有這個新建立的函數,有看到下圖的畫面就大功告成囉!
![]()  | 
|---|
| 圖2 :剛建好的資料庫函數 | 
函式建好了,但如果資料量變大,沒有索引的搜尋會非常慢。現在,讓我們為它加上索引來提升效能。同樣在「SQL Editor」中,你可以開一個新的查詢,或者直接刪掉舊的內容,貼上只有建立索引的程式碼:
-- 索引 1:為向量搜尋建立 HNSW 索引,大幅加速餘弦距離計算
create index if not exists documents_embedding_hnsw
  on public.documents
  using hnsw (embedding vector_cosine_ops);
-- 索引 2:為 question_id 建立標準 B-Tree 索引,加速範圍過濾
create index if not exists documents_question_id_idx
  on public.documents (question_id);
再次點擊「RUN」。這次執行可能會花幾秒鐘,因為資料庫正在為我們現有的所有資料建立索引。完成後,我們的搜尋引擎就正式準備就緒了!
第一段:
第二段:
綜合起來就是:
補充說明:
在目前資料量極少的情況下,坦白講全表搜尋還是會比索引搜尋快速,你實際執行搜尋時不用到索引的可能性也很高!但未來或是一個真正上線的產品不可能只有這點資料量,因此雖然我也只是這方面的菜雞,我也還是想盡可能的提一下並作基礎的示範。
函式建好了,必須立刻測試它。我們來寫一個新的測試腳本,驗證它是否真的能做到精準搜尋。
在 scripts/ 資料夾下建立 test-search.js:
// scripts/test-search.js
import { createClient } from '@supabase/supabase-js';
import { GoogleGenAI } from '@google/genai';
import dotenv from 'dotenv';
dotenv.config({ path: './.env.local' });
async function testSearch() {
  // 1. 模擬使用者回答 hoisting 問題
  const userAnswer = "變數宣告會被拉到程式碼最上面,但賦值會留在原地";
  const targetQuestionId = 'js-con-001'; // 我們要針對 hoisting 問題進行搜尋
  console.log(`[測試] 使用者回答: "${userAnswer}"`);
  console.log(`[測試] 搜尋目標問題 ID: ${targetQuestionId}`);
  // 2. 初始化客戶端
  const gemini = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
  const supabase = createClient(
    process.env.SUPABASE_URL,
    process.env.SUPABASE_SERVICE_KEY
  );
  try {
    // 3. 將使用者回答轉換成查詢向量
    console.log('[測試] 正在產生查詢向量...');
    const response = await gemini.models.embedContent({
        model: 'gemini-embedding-001',
        contents: [userAnswer],
        config: { outputDimensionality: 768 },
    });
    const queryEmbedding = response.embeddings[0].values;
    console.log(`[測試] 查詢向量產生成功 (維度: ${queryEmbedding.length})`);
    // 4. 呼叫資料庫函式進行搜尋!
    console.log('[測試] 正在呼叫 Supabase 資料庫函式 "match_documents"...');
    const { data, error } = await supabase.rpc('match_documents', {
      query_embedding: queryEmbedding,     // 傳入我們的查詢向量
      p_question_id: targetQuestionId,   // 【關鍵】傳入我們要搜尋的問題 ID
      match_threshold: 0.7,              // 設定一個合理的相似度門檻
      match_count: 5,                      // 最多找 5 筆
    });
    if (error) {
      throw new Error(`RPC 呼叫失敗: ${error.message}`);
    }
    console.log('✅ 成功從資料庫中檢索到以下相關資料:');
    if (data && data.length > 0) {
      console.table(data);
    } else {
      console.log('找不到任何相關資料,可以試著調低 match_threshold 看看。');
    }
  } catch (error) {
    console.error('測試過程中發生錯誤:', error.message);
  }
}
testSearch();
執行這個腳本:
node scripts/test-search.js
如果一切順利,你應該會在 console 中看到一個漂亮的表格,像是這樣的輸出結果:
[測試] 使用者回答: "變數宣告會被拉到程式碼最上面,但賦值會留在原地"
[測試] 搜尋目標問題 ID: js-con-001
[測試] 正在產生查詢向量...
[測試] 查詢向量產生成功 (維度: 768)
[測試] 正在呼叫 Supabase 資料庫函式 "match_documents"...
✅ 成功從資料庫中檢索到以下相關資料:
┌─────────┬────┬────────────────────────────────────────────────────────────────────────────────┬───────────────────┐
│ (index) │ id │ content                                                                        │ similarity        │
├─────────┼────┼────────────────────────────────────────────────────────────────────────────────┼───────────────────┤
│ 0       │ 3  │ '只有宣告被提升,賦值不會'                                                     │ 0.853378674637268 │
│ 1       │ 2  │ '變數和函數宣告會被提升到其作用域的頂部'                                       │ 0.843688937046709 │
│ 2       │ 4  │ 'let 和 const 也有 hoisting,但因存在暫時性死區 (TDZ),在宣告前存取會拋出錯誤' │ 0.71679967555255  │
└─────────┴────┴────────────────────────────────────────────────────────────────────────────────┴───────────────────┘
裡面列出的 content 全部都是 關於 hoisting (js-con-001) 的 keyPoints,並按照相似度分數由高到低排序。這證明了我們的搜尋引擎不僅能運作,而且非常精準!未來概念問題測驗時,只要將使用者的回答與向量資料庫比對,我們就可以模擬語意理解的行為,讓 AI 更好判斷使用者的回答是否真的有打到要點上,而不是靠模糊的比對甚至猜測!
今天我們完成了 RAG 流程中至關重要的一步,為我們的 AI 大腦裝上了高效且精準的「記憶檢索系統」。
✅ 我們學會了如何批量地將知識向量化並存入資料庫。
✅ 我們掌握了如何利用資料庫函式,將繁重的計算任務交給資料庫處理。
✅ 我們成功打造了一個能進行精準語意搜尋的函式,確保了檢索結果的相關性。
✅ 我們完成了 RAG 藍圖中,最核心的「R (Retrieval)」步驟!
檢索 (Retrieval) 的問題解決了,我們的後端現在有能力找出最相關的參考資料。下一步,就是將這些資料與使用者的回答組合起來,交給 AI 進行「生成 (Generation)」,並將結果即時地回傳給使用者,這樣大致上概念問題的回答已經可以處理的八九不離十了,剩下串接與測試而已。
明天 (Day 12),我們會將目光移開 Supabase 與向量相關的東西,我們要開始做程式碼執行的基本處理,未來兩天我們需要理解怎麼讓使用者的程式碼安全的執行,我們又該怎麼判斷他是否正確並給出回饋!
我們明天見!
今日程式碼: https://github.com/windate3411/Itiron-2025-code/tree/day-11
Supabase 官方文件 - Database Function
Supabase 官方文件 - Edge Functions